查看原文
其他

【第2425期】浅谈 Typescript(二):基础类型和类型的声明、运算、派生

几木 前端早读课 2021-10-29

前言

今日前端早读课文章由滴滴@几木授权分享。

@王宏宇,也叫几木,爱好自驾撸猫烫头,来自滴滴小桔车服,致力于体验和产研效率提升,欢迎加入 why318why@gmail.com

正文从这开始~~

【第2424期】浅谈 Typescript(一):什么是Typescript?我们了解到,Typescript 构造了两个相对独立的空间。这篇我们先把目光放在「类型声明空间」的表现,即基础类型和类型的声明与运算。

本文你将看到:

  • Typescript 可以直接拿来用的基础类型

  • Typescript 声明类型的方式和区别,一些核心用法的理解

  • Typescript 类型的运算和派生

本文你不会看到:

  • Typescript 各种语法和API的详细罗列

  • Typescript 配置项的罗列和解释

1 基础类型

我们先来关注「类型声明空间」的万物之源 —— 基础类型(上图框起来的部分)。「类型声明空间」中的基础类型,就像「变量声明空间」中的 1, 'string', true...。在类型声明空间中,我们既可以直接使用基础类型,也可以通过声明、运算、派生来构造、流转新的类型。

const foo: number = 10 // 直接使用
type bar = number // 赋值给类型声明
number / string / boolean / null / undefined

「类型声明空间」有五种基础类型用来对应「变量声明空间」中,JS 的五种原始值。

const n: number = 10;
const s: string = 'string';
const b: boolean = true;
const n: null = null;
const u: undefined;
null / undefined 是所有类型的子类型

默认情况下null和undefined是所有类型的子类型。就是说你可以把 null和undefined赋值给number类型的变量。

你可以给一个有类型的变量赋值同样类型的值,也可以赋值为null / undefined ,他们能单向给任意别的类型赋值。

any

强制关闭当前变量的类型检查。

any 类型在 TypeScript 类型系统中占有特殊的地位。它提供给你一个类型系统的「后门」,TypeScript 将会把类型检查关闭。在类型系统里 any 能够兼容所有的类型(包括它自己)。因此,所有类型都能被赋值给它,它也能被赋值给其他任何类型。

let foo: any = 1;
foo = 'string'; // 没问题,因为 any 把 foo 的类型检查关闭了
反应函数返回的类型 void / never
void

主要用在函数声明中,表示没有返回值的函数的返回值类型。

const VoidFunc = (): void => {};
const returnOfVoidFunc = VoidFunc(); // void

那我直接声明一个 void 类型会怎么样呢?这是没什么意义的,因为你只能给它赋值为null / undefined。

const aVoid: void = undefined;
never

不返回和没有返回值是两回事。和 void 不同,never 是 TypeScript 中的底层类型,表示那些永不存在的值的类型。

放到函数里说,就是一个不会返回的函数的返回值类型。什么情况下函数会没有返回呢?两种情况:

  • 函数内有死循环

  • 函数总抛错

// 1. 函数内有死循环
const NeverFunc = (): never => {
while(true) {}
};
// 2. 函数总抛错
const NeverFunc = (): never => {
throw new Error();
};

也可以当类型注解,但只能赋值为另外一个 never,可以放在一段「我认为永远执行不到的代码段」内做「类型保护」(关于类型保护,后面文章会说)。

const neverRetch: never;
数组和元组

数组类型有两种声明方式(其中第二种是「范型」的应用,将在后面介绍)

const arr: number[] = [1,2,3];
const arr: Array<number> = [1,2,3];

如果你确定数组的元素数量和类型(甚至是不同的类型),则可以用更严谨的类型——元组。元组在「变量声明空间」也完完全全是个数组,只是在「类型声明空间」比数组类型更精细。

const status: [ number, string ] = [ 1, '已完成' ];
// 如果你从元组中取某个元素,得到的类型也是正确的
status[0] // number
// 如果越界了,得到联合类型
status[2] // number | string

2 类型声明

上一章我们介绍了一些基础类型,基础类型是可以「直接拿用来注解的」,就像我们在 JS 里可以直接console.log(1)一样。但有时候我们需要通过类型声明,在基础类型的基础上,进行类型的别名、组合,和复杂类型的创建,就像 JS 里 const、let、var、function、class 等关键字。

同样,在类型声明空间中,也可以通过一些关键字声明类型:

  • type:声明一个类型别名

  • interface:声明一个接口

  • class:声明一个类

  • enum:声明一个枚举

  • namespace:声明命名空间

  • module:声明模块

这些关键字不仅在声明产物上更复杂,在运行机制上也有所区别,如下图:

type、interface 是「纯类型声明」,只在类型声明空间产生声明;其中type 要比 interface 更灵活,可以赋值为基本类型或其他声明产生的类型

class 本来是 js(ES)的语法,ts 做了补充,使之能同时产生一个类型声明

enum、namespace 是「对变量声明空间有扩展的类型声明」,不但在类型声明空间产生声明,也在变量声明空间构建了特殊的数据结构。要注意两个空间中的声明的关系和区别,不然很容易搞混。

编译行为对类型声明空间的剔除

如图所示,有的声明影响绿色的类型声明空间,而有的“污染”了黄色的变量声明空间。无论如何,JS 在编译后都会把绿色部分剔除掉,而黄色部分转换为可执行的JS结构。两个例子:

/* === 1、type 声明 === */
type t = number
const foo: t = 1
// 编译后
const foo = 1

/* === 2、enum 声明 === */
enum Color {
Red = 0,
Green = 1,
Blue = 2
}
// 编译后
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
type

type 在类型声明空间中,相当于变量声明空间的 const/let/var,用来声明一个类型别名。

type t = number
const foo: t = 1

你可以在声明等号右侧放任何可以直接注解的东西,比如「原始类型、对象/函数声明、传递其他类型声明、类型运算和派生表达式、类型捕获表达式」,包括“接口”,如下:

type Foo = {
bar: number;
}

interface

TypeScript的核心原则之一是对值所具有的结构进行类型检查。

那么如何检查一个复杂的值结构呢?interface 可以给::对象、函数::定义类型。可以理解为::「一个可调用类型及其调用模式」::。和 type 一样,interface 并不污染变量声明空间。

接口是对调用模式的描述

对象和函数都是可调用的:对象通过foo[bar]调用属性或方法,函数通过foo(bar)、new foo(bar)调用。

// 声明对象 interface
interface Foo {
bar: number;
}
// 声明函数 interface
interface Foo {
(bar: number): void;
}
// 声明构造函数 interface
interface Foo {
new (bar: number): Foo
}

灵活的 interface 声明

此外,interface 提供多种关键词实现灵活的声明语法,主要的几个如下:(具体不展开,参考接口 · TypeScript中文网 · TypeScript——JavaScript的超集)

  • 索引类型

  • readonly:只读接口属性

  • extends:接口扩展

  • implements:接口实现

type/直接注解和interface声明的区别

前面说了,我直接搞一个{ bar: number }也能直接注解对象,或者赋值给type,那么type 声明/直接注解的和 interface 声明的有什么区别呢?

interface 可以 merge(重复声明,并合并属性),如下,但 type 不行

interface Foo {
bar: string;
}
interface Foo {
baz: number;
}

const foo: Foo = { bar: 'bar', baz: 1 };

参考:TypeScript: Documentation - Everyday Types

class

class 本来是 js的语法,关于它在变量声明空间的用法就不展开了。

class 在类型空间

当你在 ts 中声明一个 class Foo,它会保留 js 中的 class Foo 声明,同时在类型空间声明一个类型 Foo(两空间命名可以重复)。这个类型 Foo 表示的是:::Foo 类的实例类型。::,可以直接当成 interface 来用。

class Foo {
bar: number;
baz() {}
}
const foo: Foo = new Foo()

// 等价 interface
interface Foo {
bar: number;
baz: () => void;
// 成员类型既可以是明确注解的,也可以是推测出来的
}

class 实例和 interface 的区别

最重要的一点是,要拎清楚,interface 是纯「类型声明空间」的产物,编译就没了;但 class 可是有实实在在的「变量声明空间」实现,也因此class 可以正常被实例化、继承、调用,interface 不行。

有人说那我 declare 一个 class 当 interface 用不行吗?做人要负责的,既然 declare 了 class,TS 就会认为你真的有 class 实现,如果你还真没有 class 实现(比如从JS引入的),那就是个坑了。

enum

enum 是 ts 创造的一种声明,表示枚举,用法如下面代码:

enum Color {
Red,
Green,
Blue,
}
// 创建了三个 Color 枚举,可以这样引用
const red = Color.Red; // 这里 red 其实会被赋值为 0,枚举的默认行为是以 0,1,2... 类似 index 的方式赋值

显然,enum 已经干扰到变量声明空间的赋值行为了。因此 enum 不仅在类型空间产生声明,也在变量空间构建了特殊结构。下面我们讨论下它在类型声明空间和变量声明空间的表现。

enum 在类型声明空间

上面声明的 Color 在类型空间,相当于声明了一个和枚举类型相同的 Color 类型(这里是 number)

const color: Color = 0;
// 相当于
const color: number = 0;
// 注意是 number,而不是 0|1|2,因此你这样赋值也不会报错
const color: Color = 999;

enum 在变量声明空间

那么如何实现枚举能力呢,编译后可以看到,enum 会在变量空间创建一个「有枚举特性」的 Color 变量:

// 编译后
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

这段代码创建了这样一个 Color 变量:{0: "Red", 1: "Green", 2: "Blue", Red: 0, Green: 1, Blue: 2}

// 我们可以通过枚举成员拿到它的关联值
const red: Color = Color.Red
// 也可以通过关联值拿到枚举成员名,这在反查的时候非常有意义
const key: string = Color[0]

鸡肋的字符串关联值

为了更语义化,ts 还支持字符串类型关联值:

enum Color {
Red = 'red',
Green = 'green',
Blue = 'blue',
}

我个人不喜欢这么用,因为所谓的语义化并不那么必要,连枚举成员名都解释不清楚的枚举还叫枚举么?更主要的是,用字符串丧失了「反查」的能力:

// 编译后的字符串类型关联值枚举
var Color;
(function (Color) {
Color["Red"] = "red";
Color["Green"] = "green";
Color["Blue"] = "blue";
})(Color || (Color = {}));

也因此,丧失了直接赋值的能力:

const c: Color = 'green'; // Type '"green"' is not assignable to type 'Color'.
namespace

js 中,我们通常用一个自执行函数包装出一个「命名空间」,这样 foo 不会污染到外层变量命名空间,而只能通过 something 访问到。

(function(something) {
something.foo = 123;
})(something || (something = {}))
;

ts 提供了 namespace(以前叫内部模块),在变量空间封装了这种做法,同时在类型空间提供被包裹的声明方式。

namespace something {
export const foo: number = 123
export interface Foo {
bar: string;
}
}
namespace 在变量声明空间

很显然,namespace 要侵入变量声明空间。上面声明编译后:

// 和我们自己的包法,一模一样
var something;
(function (something) {
something.foo = 123;
})(something || (something = {}));
// 可以通过层级访问到内部变量
console.log(something.foo);
// (foo 留着;interface Foo 部分被编译清掉了)
namespace 的更多玩法

除了最简单的包裹变量声明、类型声明,namespace 还可以做到:(参考 命名空间 · TypeScript中文网 · TypeScript——JavaScript的超集)

  • 多层嵌套

  • 多文件共同维护一个 namespace 声明

  • 为任意层创建别名

module

为什么说「namespace 以前叫内部模块」呢?因为以前没有 namespace 关键词,命名空间的能力是通过 module 实现的。

module something {
export const foo: number = 123
}

后来换了更贴切的 namespace,module 也就只用在「外部模块」上了。现在,我们通常只能在 declare 时看到 module 用于补充外部 js 模块的类型。

// path.d.ts
declare module 'path' {
export const path: any;
}

3 类型运算和派生

对于基础类型、已声明的类型,我们可以通过运算和派生转换为另外一种类型,拿来直接注解或者给类型声明用。

联合类型

在某些场景下,我们需要表示「可能的多种类型」,比如一个fontWeight,值可能是700或者'bold'。在 TS 中是这样表示的:

const fontWeight: string | number = 700;
字面量类型

更具体的,我们可以直接以联合的形式声明字面量类型。随后的赋值只能以被联合的字面量之一,有种乞丐版枚举的意思。

// 字符串字面量
const display: 'flex' | 'block' = 'block';
// 数字字面量
const status: 0 | 1 | 2 | 3 = 2;
联合类型和类型保护

通俗点说,当你用一些逻辑让联合类型注解的变量走到分支,它的联合类型也会缩小范围。

// 比如一个函数
const getStyle = (display: 'flex' | 'block') => {
if (display === 'flex') {...}
else {
// 这里,display 的类型就会缩减到 'block' 上
}
}

事实上 TS 更智能,甚至能通过两个联合的接口的属性类型来剔除“不可能的部分”。这种特性叫“可辨识联合”,后面会详细介绍。

交叉类型

和联合类型的“或”不同,交叉类型是“且”,把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。比较常见的场景是接口合并,比如:

interface BaseInfo {
name: string;
id: number;
}
interface ConnectInfo {
phone: number;
mail: string;
}
type PersonInfo = BaseInfo & ConnectInfo;
// 这样,PersonInfo 接口需要同时实现 BaseInfo 和 ConnectInfo 的属性声明
范型

我们回顾前面数组声明的第二种用法:

const arr: Array<number> = [1,2,3];

可以这样理解:我们以 <>的形式为类型Array传入类型number,就得到了一个包装后的“元素类型为 number 的 Array”。就像一个类型工厂,或者类型函数。

但范型更重要的意义是,一旦范型 T(或者别的什么)在入口定义,其内部所有用到 T 的地方都将保持该“可变类型”的一致性。

interface 中的范型

比如我们要封装一个网络请求的返回类型,它外层有通用的 code,然后通过 data 链接到“不可预测”的数据结构上。这时通过传入的范型,就可以获得一个 data 类型很具体的返回类型。

// interface 的范型入口在声明后
interface ResponseType<DataType> {
code: number,
data: DataType
}
// 调用
type myResponse = ResponseType<string> // 获得一个 { code: number; data: string; } 的类型
class 中的范型

比如我们有个 Queue 列,但无法确定队列元素的类型,就可以开放范型。(当然你也可以直接置为 any,就丧失了对元素类型的准确控制)

// class 的范型入口在声明后
class Queue<T> {
private data: T[] = [];
push = (item: T) => this.data.push(item);
pop = (): T | undefined => this.data.shift();
}

// 调用
const queue = new Queue<number>();
queue.push(0);
queue.push('1'); // Error:不能推入一个 `string`,只有 number 类型被允许
函数中的范型

同样是Queue 的函数版,参数和返回都能取到传入的类型。

// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
return [firstItem];
}
// 调用
getQueue<number>(1)
getQueue<number>('Robin') // Error

不必传入:范型推论

编译器会根据传入的参数自动地帮助我们确定T的类型

比如上面函数,我直接调用 getQueue,T 作为入参,会被推断为number,并保持整个声明内一致,即返回值为number[]。

// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
return [firstItem];
}
// 调用
getQueue(1)

不要无脑上范型

范型主要用于保持声明内部的一致,不要为了用而用。如果只有一处用到,自然不存在一致的问题,相比用范型,直接用 any 把类型“做掉”更简明。

const foo = <T>(bar: T): void => {}
// 不如这样
const foo = (bar: any): void => {}

索引类型:查询和访问

keyof:索引查询

有时我们需要获取一个 interface 的索引类型(比如解析页面参数的时候),就要用 keyof 关键字。索引类型可能是字面量或者别的,要看 interface 是如何构造的。keyof 相当于把接口的索引归纳到一个类型上。

// interface 以具体 key 声明
interface UrlQuery {
from: string;
resourceId: string;
}
// keyof 取出的是字面量类型
type KeyType = keyof UrlQuery; // KeyType 为 'from' | 'resourceId';

// interface 以可索引声明
interface UrlQuery {
[q: string]: string;
}
// keyof 取出的是索引类型
type KeyType = keyof UrlQuery; // KeyType 为 string | number

注意最后一个 case,为什么是 string | number,参考 typescript - Keyof inferring string | number when key is only a string - Stack Overflow

索引访问

在一个已声明的接口或数组上,我们可能需要获取某个接口属性或者数组元素的类型

interface UrlQuery {
from: string;
resourceId: string;
}
// 获取接口属性的类型
UrlQuery['from'] // string

type QueueType = string[];
// 获取数组元素的类型
QueueType[0] // string,给 1、2... 都可以

映射类型

前面的 keyof,是吧接口的索引归纳到一个类型上。在 TS 中,同样有对应的反向操作,即把索引的联合类型映射回来。

type Params = 'from' | 'resourceId';
type UrlQuery = {
[P in Params]: string;
}

有什么用呢?比如我们实现一个 Partial,也就是把传入接口的所有属性都变为可选的,结合索引和映射:

type Partial<T> = {
[P in keyof T]?: T[P];
}

注意:interface 不支持

所以我们在例子里只能用 type 声明,在很多时候是一样用的。

Utility Types

如上我们实现的 Partial,其实早已被 TS 内置,此外 TS 还提供了更多工具类型,都是以范型的形式存在的,特别方便于我们在类型声明空间中进行类型的转换。常见的如:

  • Partial、Required、Readonly:用于映射接口属性的可选性

  • Exclude、Extract:用于调整接口的属性列表

  • ReturnType:获取函数返回类型

完整列表参考:TypeScript: Documentation - Utility Types

小结

本篇我们主要围绕「TS 在类型声明空间中的行为」展开讨论。

  • TS 在类型声明空间中,像 JS 一样,提供一些基础值、声明和运算语法,基础类型被后两者反复加工,衍生出非常丰富的类型。

  • 基础类型主要针对 JS 的几种基础值,做对应的注解

  • 类型声明可以自定义类型名称,声明丰富的类型结构,有的甚至能干扰到「变量声明空间」

  • 各种运算和派生语法,能把一种或几种类型转换为其他类型,进一步增加了「类型声明空间」的丰富程度

关于本文
作者:@几木
原文:https://zhuanlan.zhihu.com/p/389888266

浅谈Typescript系列


【第2424期】浅谈 Typescript(一):什么是Typescript?


欢迎自荐投稿,前端早读课等你来。

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存